今天我們分享decorator class裝飾於function上的情況。本日接下來內容,會以decorator來作為decorator class的簡稱。
另外,有些於decorator function提過的細節,將不特別重複,直接入進本日重點。
decorator的核心概念為接受一個function,從中做一些操作,最後返回一個class的instance。一般來說,返回的instance是個callable,會接收與原function相同的參數,並返回相同的結果,但卻能具有decorator額外賦予的功能。
# 01
class dec:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs)
@dec
def my_func(*args, **kwargs):
pass
decorator,名為dec。其接收一個function,但卻返回一個dec生成的instance,稱作dec_instance。接收的function會被指定為dec_instance的instance variable self.func。dec裝飾的function,名為my_func,其可接受*args及**kwargs。藉由decorator,my_func已經從原先的my_func變成dec_instance了。由於__call__(註1)與原先的my_func接收相同的參數(即*args及**kwargs),所以裝飾前後,my_func的呼叫方式是一致的。
當呼叫my_func時,實際上是在呼叫dec_instance。舉例來說,此時的my_func(1, 2),相當於呼叫dec_instance(1, 2),即dec_instance.__call__(1, 2)。而dec_instance.__call__則返回原先傳入的self.func搭配上args = (1, 2),kwargs = {}這些參數的計算結果。
__get__)# 01的code有個潛在問題,就是當它裝飾在class內的function時,如01a,會raise TypeError。
# 01a
class dec:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs)
class MyClass:
@dec
def my_func(self, *args, **kwargs):
pass
if __name__ == '__main__':
my_inst = MyClass()
my_inst.my_func() # TypeError
01a的錯誤訊息為MyClass.my_func() missing 1 required positional argument: 'self'。這是怎麼一回事呢?
我們一樣回到核心原理開始思考,雖然MyClass中的my_func原本是個function,但經過dec的裝飾後,my_func已經變作dec_instance,所以上述的MyClass可視為下面這個寫法的簡潔版(記得@dec是語法糖嗎?)。
# 01a
class MyClass:
def my_func(self, *args, **kwargs):
pass
my_func = dec(my_func)
這麼一來,很清楚的看出my_func是位於MyClass中,由dec所建立的一個dec_instance。此時my_inst.my_func(),相當於dec_instance作為一個callable來呼叫dec_instance.__call__(),而其__call__需接收一個self參數,及選擇性給予的*args及**kwargs。由於我們沒有傳參數給__call__,所以Python提醒我們最少需要給self參數,才能呼叫成功。
或許您還是有疑惑,為什麼我們明明使用my_inst.my_func(),為什麼my_inst沒有自動傳遞給my_func,作為第一個參數self呢?那是因為function是一種non-data descriptor,其具備有__get__,並於其內使用MethodType來將descriptor的instance與呼叫其的instance bound在一起,所以才會有我們習慣的自動傳遞instance到function中,作為第一個參數self的行為。如果有些不明白的話,我們會於後續導讀Descriptor HowTo Guide的部份再詳談。
重點是,當使用decorator class裝飾class中的function,實作__get__可以讓它用起來,就像是一般的instance method(註2),如# 02。
# 02
from types import MethodType
class dec:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs)
def __get__(self, instance, owner_class):
if instance is None:
return self
return MethodType(self, instance)
class MyClass:
@dec
def my_func(self, *args, **kwargs):
pass
if __name__ == '__main__':
my_inst = MyClass()
my_inst.my_func() # ok
dec的__get__中的MethodType(self, instance)會幫忙將dec_instance與my_inst bound在一起。
基本型態1與基本型態2的寫法皆會喪失被裝飾function的metadata。一個折衷的辦法是將這些metadata更新到dec_instance.__dict__,即__init__中的update_wrapper(self, self.func)。
# 03
from functools import update_wrapper
from types import MethodType
class dec:
def __init__(self, func):
self.func = func
update_wrapper(self, self.func)
def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs)
def __get__(self, instance, owner_class):
if instance is None:
return self
return MethodType(self, instance)
# 04透過decorator來logging一些資訊:
# 04
import logging
from functools import update_wrapper
from types import MethodType
class log:
def __init__(self, func):
self.func = func
update_wrapper(self, self.func)
def __call__(self, *args, **kwargs):
logging.info(f'__call__ is called, {self.func=}, {args=}, {kwargs=}')
return self.func(*args, **kwargs)
def __get__(self, instance, owner_class):
if instance is None:
return self
return MethodType(self, instance)
@log
def add(a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
class MyClass:
@log
def add(self, a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO)
my_inst = MyClass()
print(add(1, 2)) # 3
print(my_inst.add(1, 2)) # 3
在__call__中,我們加了一行logging.info來協助記錄每次log生成的instance被呼叫時,其實際使用的func、args及kwargs。
INFO:root:__call__ is called, self.func=<function add at 0x0000015CBE110C20>, args=(1, 2), kwargs={}
3
INFO:root:__call__ is called, self.func=<function MyClass.add at 0x0000015CBE88A160>, args=(<__main__.MyClass object at 0x0000015CBE884950>, 1, 2), kwargs={}
3
可以順便觀察metadata更新的狀況。
add=<__main__.log object at 0x0000015CBE4CF910>
add.__module__='__main__'
add.__name__='add'
add.__doc__='Take two integers and return their sum.'
add.__qualname__='add'
add.__annotations__={'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}
add.__dict__={'func': <function add at 0x0000015CBE110C20>, '__module__': '__main__', '__name__': 'add', '__qualname__': 'add', '__doc__': 'Take two integers and return their sum.', '__annotations__': {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}, '__wrapped__': <function add at 0x0000015CBE110C20>}
my_inst.add=<bound method MyClass.add of <__main__.MyClass object at 0x0000015CBE884950>>
my_inst.add.__module__='__main__'
my_inst.add.__name__='add'
my_inst.add.__doc__='Take two integers and return their sum.'
my_inst.add.__qualname__='MyClass.add'
my_inst.add.__annotations__={'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}
my_inst.add.__dict__={'func': <function MyClass.add at 0x0000015CBE88A160>, '__module__': '__main__', '__name__': 'add', '__qualname__': 'MyClass.add', '__doc__': 'Take two integers and return their sum.', '__annotations__': {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}, '__wrapped__': <function MyClass.add at 0x0000015CBE88A160>}
當我們希望有一個flag來控制這個decorator是否要logging,可以寫成# 05:
# 05
import logging
from functools import wraps
class log:
def __init__(self, to_log=True):
self.to_log = to_log
def __call__(self, func):
@wraps(func)
def wrapper(*args, **kwargs):
if self.to_log:
logging.info(
f'__call__ wrapper is called, {func=}, {args=}, {kwargs=}')
return func(*args, **kwargs)
return wrapper
@log()
def add(a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
class MyClass:
@log()
def add(self, a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO)
my_inst = MyClass()
print(add(1, 2)) # 3
print(my_inst.add(1, 2)) # 3
decorator factory一樣,可施加於一般function,及class內的function。
@log()中的log()會先得到log的instance,稱作log_instace。由於log有實作__call__,所以log_instace為callable。@log()相當於@log_instance(add)(to_log的資訊已傳至self.to_log)。由於__call__是一個instance method,log_instance將自動傳遞至__call__作為第一個參數self。__call__會回傳wrapper,其接受參數與add相同,並會返回相同結果,只是額外針對self.to_log的值來決定是否進行logging。所以最終呼叫add相當於呼叫wrapper。wrapper function,不再是基本型態中的instance,所以我們可以直接像在decorator function中一樣,使用較為方便的functools.wraps。值得一提的是,這個情況我們不需替log實作__get__,即可施加於class內的function。原因是這次我們返回的是wrapper,其本身是function,function本身就有實作__get__,所以當使用my_inst.add(1, 2),其會返回一個bound好add及my_inst的MethodType instance來接收1跟2這兩個參數。
# 06
import logging
from functools import wraps
from numbers import Real
from typing import get_type_hints
class log:
def __init__(self, *, to_log=True, validate_input=True):
self.to_log = to_log
self.validate_input = validate_input
def __call__(self, func):
@wraps(func)
def wrapper(*args, **kwargs):
if self.to_log:
logging.info(
f' `wrapper` is called, {func=}, {args=}, {kwargs=}')
if self.validate_input:
n = len(args) + len(kwargs)
type_hints = get_type_hints(func)
if n and n+1 > len(type_hints): # return is included in type_hints
if self.to_log:
logging.error(
f'Annotations={type_hints}, {args=}, {kwargs=}')
raise TypeError('Some annotations might be missing.')
if args and not all(isinstance(arg, type_)
for arg, type_ in zip(args, type_hints.values())):
if self.to_log:
logging.error(
f'Annotations={type_hints}, {args=}, {kwargs=}')
raise TypeError(
f'Possible incorrect type assignment in {args=}')
if kwargs and not all(isinstance(kw_value, type_)
for name, type_ in type_hints.items()
if (kw_value := kwargs.get(name))):
if self.to_log:
logging.error(
f'Annotations={type_hints}, {args=}, {kwargs=}')
raise TypeError(
f'Possible incorrect type assignment in {kwargs=}')
result = func(*args, **kwargs)
if self.validate_input:
expected_return_type = type_hints['return']
if not isinstance(result, expected_return_type):
logging.warning(
f' Return value: {result}(type={type(result)}) is not an '
f'instance of {expected_return_type}')
if self.to_log:
logging.info(' `wrapper` is finished.')
return result
return wrapper
@log(to_log=True, validate_input=True)
def add(a: Real, b: Real) -> Real:
"""Take two reals and return their sum."""
return a + b
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG)
r = add(1.2, b=2.3)
print(r, type(r)) # 3.5, float
INFO:root: `wrapper` is called, func=<function add at 0x0000024A7C3C4EA0>, args=(1.2,), kwargs={'b': 2.3}
INFO:root: `wrapper` is finished.
3.5 <class 'float'>
# 06與[Day05]的# 07寫法不同的地方,只在:
# 06將接收的參數邏輯放在__init__。# 06將wrapper放在__call__。如果想要同時能夠使用@log及@log()兩種語法,勢必要面對回傳值有時是function,有時是instance的情況,所以相關metadata的處理也要記得分開處理,我們做了一些嘗試(註3)。
相較之下,我們會建議使用一個function來包住一個decorator class,如# 07所示。原理其實和decorator function的# 08差不多。
# 07
import logging
from functools import wraps
class log:
def __init__(self, *, to_log=True):
self.to_log = to_log
def __call__(self, func):
@wraps(func)
def wrapper(*args, **kwargs):
if self.to_log:
logging.info(
f'__call__ wrapper is called, {func=}, {args=}, {kwargs=}')
return func(*args, **kwargs)
return wrapper
def logf(func=None, /, *, to_log=True):
if func is None:
return log(to_log=to_log)
return log(to_log=to_log)(func)
@logf()
def add1(a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
@logf
def add2(a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
class MyClass:
@logf()
def add1(self, a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
@logf
def add2(self, a: int, b: int) -> int:
"""Take two integers and return their sum."""
return a + b
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO)
print(add1(1, 2)) # 3
print(add2(1, 2)) # 3
my_inst = MyClass()
print(my_inst.add1(1, 2)) # 3
print(my_inst.add2(1, 2)) # 3
若檢查所有metadata,也都有一起更新。
INFO:root:__call__ wrapper is called, func=<function add1 at 0x000001D48F3C4E00>, args=(1, 2), kwargs={}
3
INFO:root:__call__ wrapper is called, func=<function add2 at 0x000001D48F886200>, args=(1, 2), kwargs={}
3
INFO:root:__call__ wrapper is called, func=<function MyClass.add1 at 0x000001D48F8863E0>, args=(<__main__.MyClass object at 0x000001D48F8886D0>, 1, 2), kwargs={}
3
INFO:root:__call__ wrapper is called, func=<function MyClass.add2 at 0x000001D48F886520>, args=(<__main__.MyClass object at 0x000001D48F8886D0>, 1, 2), kwargs={}
3
基本型態1時,decorator可施加於一般的function。基本型態2時,decorator可施加於一般的function及class內的function上。基本型態3時,decorator可施加於一般的function及class內的function上,且被裝飾function的metadata會更新至decorator生成的instance內。decorator factory最終會返回的是function,其本身已具有__get__,所以不用額外處理。常用型態時,建議使用一個function包住一個decorator class使用,我們覺得會比註3的寫法更簡單優雅。註1:當class內有實作__call__,該class生成的instance則為callable。
註2:其實對於classmethod或staticmethod上,decorator class也是可以用的,只是必須注意順序,要得先@log再加上@classmethod或@staticmethod。
# 101
class MyClass:
@classmethod
@log
def class_method(cls):
pass
@staticmethod
@log
def static_method():
pass
註3:# 102中,除了需要同時考慮兩種邏輯,還要記得實作__get__,相比於# 07的寫法複雜不少。
# 102
import logging
from functools import update_wrapper
from types import MethodType
class log:
def __init__(self, func=None, /, *, to_log=True):
self.func = func
self.to_log = to_log
if func is not None:
update_wrapper(self, func)
def _make_wrapper(self, func):
def wrapper(*args, **kwargs):
if self.to_log:
logging.info(
f'__call__ inner is called, {func=}, {args=}, {kwargs=}')
return func(*args, **kwargs)
return wrapper
def __call__(self, *args, **kwargs):
if self.func is None:
func = args[0]
wrapper = self._make_wrapper(func)
update_wrapper(wrapper, func)
return wrapper
else:
func = self.func
wrapper = self._make_wrapper(func)
return wrapper(*args, **kwargs)
def __get__(self, instance, owner_class):
if instance is None:
return self
return MethodType(self, instance)